从 servlet 到 springmvc 的演变

溯源:Servlet 到底是什么

在 Java Web 的世界里,Servlet(Server Applet)是一切的基础。它不是一个产品,也不是一个框架,而是一套 标准规范接口(由 jakarta.servlet 包定义)。

  • 它的核心定义:Servlet 是运行在 Web 服务器(如 Tomcat)中的一段 Java 程序,其核心职责非常单纯,即接收 HTTP 请求并产生 HTTP 响应。
  • 它的工作模式:如果没有 Servlet,Java 程序员需要直接操作 TCP/IP 套接字(Socket)去解析复杂的 HTTP 协议头。Servlet 规范通过 HttpServletRequest 和 HttpServletResponse 这两个对象,将枯燥的字节流封装成了易用的 Java 对象。

如果把 Web 开发比作开餐厅,Tomcat 就是厨房场地,而 Servlet 就是厨师。不管厨师炒什么菜,都必须遵循一套 “接单、做菜、上菜” 的标准流程,这套流程就是 Servlet 规范。


进化:它与 Spring MVC 的关系

许多读者可能容易产生误解,认为 Spring MVC 是 Servlet 的替代品。事实并非如此,它们之间是 “基石” 与 “上层建筑” 的关系。

  • 继承与依赖关系:Spring MVC 并不是脱离 Servlet 运行的。Spring MVC 的核心组件 DispatcherServlet 本质上就是一个标准的 HttpServlet。
  • 从分散到集中:原生 Servlet 时代采用的是 “每个功能一个 Servlet” 的模式(如 LoginServlet, RegisterServlet)。这导致 web.xml 配置文件臃肿不堪,且逻辑极其分散。而 Spring MVC 时代采用的是 “前端控制器(Front Controller)” 模式,整个应用只有一个 Servlet 入口(即 DispatcherServlet),它像一个调度员,将请求分发给各个普通的 Java 类(Controller)。

Servlet 和 Spring MVC 关系的本质可以概括为:从 “命令式编程” 向 “声明式编程” 的跨越。原生 Servlet 强迫你的代码必须依赖 Servlet API(即你的方法里必须有 req 和 resp)。 Spring MVC 的本质是利用反射 (Reflection) 和 策略模式,把你从这些 API 中解救出来,帮你自动完成解析 URL(HandlerMapping)、转换参数(Data Binding)、渲染结果(ViewResolver)等繁琐的工作。在springmvc 中你只需要在方法上加个 @RequestMapping 或 @ResponseBody 注解,剩下的脏活累活全由框架通过底层的 Servlet API 替你完成。

这种演变的动力源自生产力。Servlet 解决了 “如何与 Web 服务器通讯” 的问题,建立了底层契约;而 Spring MVC 则解决了 “如何更高效、更优雅地编写业务逻辑” 的问题,让你把更多主要的精力集中在实际的业务开发上,而不用再配置繁琐的 servlet。


示例:写一个 Servlet 感受一下

从最简单的示例开始

引入依赖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<packaging>war</packaging>

<dependencies>
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
<version>6.0.0</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>jakarta.servlet.jsp.jstl</groupId>
<artifactId>jakarta.servlet.jsp.jstl-api</artifactId>
<version>3.0.0</version>
</dependency>
<dependency>
<groupId>org.glassfish.web</groupId>
<artifactId>jakarta.servlet.jsp.jstl</artifactId>
<version>3.0.1</version>
</dependency>
</dependencies>

编写 Servlet 类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@WebServlet("/hello")
public class HelloServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {

// 1. 设置响应类型(告诉浏览器我发的是 HTML)
resp.setContentType("text/html;charset=UTF-8");

// 2. 获取输出流
PrintWriter out = resp.getWriter();

// 3. 手动拼接 HTML(这就是以前没有 Thymeleaf/JSP 时的痛苦)
out.println("<html><body>");
out.println("<h1>你好,这是原生 Servlet!</h1>");
out.println("<p>请求参数 name 是: " + req.getParameter("name") + "</p>");
out.println("</body></html>");
}
}

配置 web.xml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="https://jakarta.ee/xml/ns/jakartaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee
https://jakarta.ee/xml/ns/jakartaee/web-app_6_0.xsd"
version="6.0">

<servlet>
<servlet-name>myHelloServlet</servlet-name>
<servlet-class>com.demo.servlet.HelloServlet</servlet-class>
</servlet>

<servlet-mapping>
<servlet-name>myHelloServlet</servlet-name>
<url-pattern>/hello</url-pattern>
</servlet-mapping>
</web-app>

无需 web.xml 的现代写法:如果你觉得写 web.xml 太麻烦,从 Servlet 3.0 开始,你可以使用注解直接在类上定义路径:

1
2
3
4
@WebServlet("/hello") // 加上这个注解,就不用在 web.xml 里写那十几行配置了
public class HelloServlet extends HttpServlet {
// ... 代码不变
}


写一个具体的小案例

下面这个案例包含最基本的登录登出、个人中心的功能。通过这个案例 ,我们来感受一下相对繁琐的 servlet API。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
webapp(与 java 和 resource 目录同级)

├── WEB-INF
│ └── web.xml

├── static
│ ├── css
│ ├── js
│ └── images

├── errors
│ ├── 404.jsp
│ ├── 500.jsp
│ └── error.jsp

└── index.jsp

注:WEB-INF 是应用的安全目录,只对服务端开放,对客户端是不可见的。所以我们可以把除了首页等安全页面暴露在 WEB-INF 之外,除此之外的所有页面都放到 WEB-INF 之下,这样就无法通过 URL 直接访问页面了。


web.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="https://jakarta.ee/xml/ns/jakartaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee https://jakarta.ee/xml/ns/jakartaee/web-app_6_0.xsd"
version="6.0">

<!-- ① 作用域对象生命周期监听(最常用)-->
<!-- 全局级监听 listener:实现 ServletContextListener 接口。在所有的 Filter 和 Servlet 实例化之前执行;在所有组件销毁后才进行最后的清理。
常用于应用的初始化:比如加载 Spring 配置文件、初始化数据库连接池、预热缓存。-->
<listener>
<listener-class>com.demo.servlet.AppInitListener</listener-class>
</listener>
<!-- 会话级监听 listener:实现 HttpSessionListener 接口。它只在 Session 创建或销毁时触发。
常用于统计:在线人数统计、记录用户访问轨迹。-->
<listener>
<listener-class>com.demo.servlet.OnlineCounterListener</listener-class>
</listener>
<!-- 请求级监听 listener:实现 ServletRequestListener 接口。每一个请求进来时都会触发。
requestInitialized 早于任何Filter最先执行,requestDestroyed晚于任何Filter最后执行。
常用于监控:记录每个请求的来源 IP、访问时长、封装请求日志。-->
<listener>
<listener-class>com.demo.servlet.MyRequestListener</listener-class>
</listener>

<!-- ② 作用域对象属性更改监听 -->
<!-- 数据的作用域 PageContext->HttpServletRequest->HttpSession->ServletContext -->
<!-- Context 属性监听器:ServletContext 属性变动时触发-->
<listener>
<listener-class>com.demo.servlet.ConfigUpdateListener</listener-class>
</listener>
<!-- Session 属性监听器:Session 属性变动时触发-->
<listener>
<listener-class>com.demo.servlet.LoginConflictListener</listener-class>
</listener>
<!-- Request 属性监听器:request 属性变动时触发-->
<listener>
<listener-class>com.demo.servlet.SensitiveDataListener</listener-class>
</listener>

<!-- filters:谁写在前面,谁优先级高 -->
<filter>
<filter-name>CharacterEncodingFilter</filter-name>
<filter-class>com.demo.servlet.CharacterEncodingFilter</filter-class>
<init-param>
<param-name>encoding</param-name>
<param-value>UTF-8</param-value>
</init-param>
</filter>
<filter-mapping>
<filter-name>CharacterEncodingFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>

<filter>
<filter-name>AuthFilter</filter-name>
<filter-class>com.demo.servlet.AuthFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>AuthFilter</filter-name>
<url-pattern>*.do</url-pattern>
<url-pattern>/admin/*</url-pattern>
</filter-mapping>

<!-- servlet -->
<servlet>
<servlet-name>MyDispatcherServlet</servlet-name>
<servlet-class>com.demo.servlet.DispatcherServlet</servlet-class>
<!--对于核心的 DispatcherServlet,通常设置为 1。这意味着 Tomcat 在启动过程中就会实例化该类并调用其 init() 方法,而不是等到第一个访问才加载。
设置成正整数或0表示数值越小,优先级越高;负整数或未配置则代表懒加载,Tomcat启动时不会理它,只有当第一个请求访问该 Servlet,容器才会实例化和初始化。-->
<load-on-startup>1</load-on-startup>
<multipart-config>
<max-file-size>2097152</max-file-size>
<max-request-size>4194304</max-request-size>
<file-size-threshold>0</file-size-threshold><!--超过阈值存硬盘,否则存内存,0表示都存磁盘-->
</multipart-config>
</servlet>
<servlet-mapping>
<servlet-name>MyDispatcherServlet</servlet-name>
<url-pattern>*.do</url-pattern>
</servlet-mapping>

<!--servlet context 级别参数设置-->
<context-param>
<param-name>configLocation</param-name>
<param-value>classpath:application-config.xml</param-value>
</context-param>

<!--session 设置-->
<session-config>
<session-timeout>30</session-timeout>
<cookie-config>
<!--禁止浏览器端的 JS 脚本读取 JSESSIONID-->
<http-only>true</http-only>
<!--表示该 Cookie 在 HTTP 和 HTTPS 协议下都可以被发送给服务器。默认就是为 false。
如果设置为true,那么 http:// 访问,浏览器会自动拦截并拒绝发送这个 Cookie。-->
<secure>false</secure>
</cookie-config>
</session-config>

<!--欢迎页-->
<welcome-file-list>
<welcome-file>login.jsp</welcome-file>
<welcome-file>index.html</welcome-file>
</welcome-file-list>

<!--异常页-->
<error-page>
<error-code>404</error-code>
<location>/errors/404.jsp</location>
</error-page>
<error-page>
<error-code>500</error-code>
<location>/errors/500.jsp</location>
</error-page>
<error-page>
<exception-type>java.lang.Throwable</exception-type>
<location>/errors/error.jsp</location>
</error-page>

<!--MIME 类型映射:tomcat等容器已经内置,除非特殊类型,不需要额外配置-->
<mime-mapping>
<extension>json</extension>
<mime-type>application/json</mime-type>
</mime-mapping>
<mime-mapping>
<extension>svg</extension>
<mime-type>image/svg+xml</mime-type>
</mime-mapping>
<mime-mapping>
<extension>woff2</extension>
<mime-type>font/woff2</mime-type>
</mime-mapping>
</web-app>

AppInitListener:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import jakarta.servlet.ServletContext;
import jakarta.servlet.ServletContextEvent;
import jakarta.servlet.ServletContextListener;

/**
* 全局Web应用监听器:
* 当容器启动应用时,它第一个被触发;当应用关闭时,它最后一个撤离。
* 你可以利用它来初始化数据库连接池、加载全局配置、或者开启一些后台定时任务。
*/
// @WebListener
public class AppInitListener implements ServletContextListener {

/**
* 当 Web 应用启动时调用(在所有 Filter 和 Servlet 实例化之前)
*/
@Override
public void contextInitialized(ServletContextEvent sce) {
ServletContext context = sce.getServletContext();

System.out.println("======= [系统启动] 正在进行初始化 =======");

// 1. 加载全局配置
String configLocation = context.getInitParameter("configLocation");
System.out.println("读取到配置文件路径: " + configLocation);

// 2. 初始化全局数据(如:从数据库加载字典表到内存)
// 一处配置,全局共享:模拟存入一个全局的系统版本号,所有页面都能通过 ${applicationScope.sysVersion} 访问
context.setAttribute("sysVersion", "v1.3-Enterprise");

// 3. 初始化连接池(假设此处初始化了你的数据源)
// DataSource ds = initDataSource();
// context.setAttribute("dataSource", ds);

System.out.println("======= [系统启动] 初始化完成 =======");
}

/**
* 当 Web 应用停止时调用(服务器关闭或应用重启)
*/
@Override
public void contextDestroyed(ServletContextEvent sce) {
System.out.println("======= [系统关闭] 正在释放资源 =======");

// 释放数据库连接池、关闭线程池等预防内存泄漏的操作
// dataSource.close();

System.out.println("======= [系统关闭] 资源释放完毕 =======");
}
}

OnlineCounterListener:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* @author KJ
* @description 监听 Session 的创建与销毁
*/
// @WebListener
public class OnlineCounterListener implements HttpSessionListener {
private static int onlineCount = 0;

@Override
public void sessionCreated(HttpSessionEvent se) {
onlineCount++; // ServletContext (Application):负责存储全局共享数据(如在线人数)。
se.getSession().getServletContext().setAttribute("onlineCount", onlineCount);
}

@Override
public void sessionDestroyed(HttpSessionEvent se) {
onlineCount--;
se.getSession().getServletContext().setAttribute("onlineCount", onlineCount);
}
}

MyRequestListener:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
* @author KJ
* @description 监听请求的创建和销毁
*/
// @WebListener
public class MyRequestListener implements ServletRequestListener {

// 请求到达服务器,即将进入Filter链之前触发
@Override
public void requestInitialized(ServletRequestEvent sre) {
HttpServletRequest request = (HttpServletRequest) sre.getServletRequest();
String uri = request.getRequestURI();
String ip = request.getRemoteAddr();

// 记录请求开始时间,存入 request 域供后续使用
long startTime = System.currentTimeMillis();
request.setAttribute("startTime", startTime);
System.out.println(">>> [请求开始] IP: " + ip + " 访问了: " + uri);
}

// 请求处理完毕,完成所有Filter调用,响应发送给客户端后触发
@Override
public void requestDestroyed(ServletRequestEvent sre) {
HttpServletRequest request = (HttpServletRequest) sre.getServletRequest();
Long startTime = (Long) request.getAttribute("startTime");
long endTime = System.currentTimeMillis();
System.out.println("<<< [请求结束] URL: " + request.getRequestURI() + " 总耗时: " + (endTime - startTime) + "ms");
}
}

ConfigUpdateListener:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* @author KJ
* @description 监听 servletContext 的增删改
*/
// @WebListener
public class ConfigUpdateListener implements ServletContextAttributeListener {

@Override
public void attributeReplaced(ServletContextAttributeEvent event) {
String name = event.getName();
// 监听名为 "systemConfig" 的全局属性修改
if ("systemConfig".equals(name)) {
Object newValue = event.getServletContext().getAttribute(name);
Object oldValue = event.getValue(); // 这里拿到的是旧值

System.out.println("📢 [系统配置变更]");
System.out.println("旧值: " + oldValue);
System.out.println("新值: " + newValue);

// 典型业务:通知其他微服务组件或重连数据库
// syncWithOtherServices(newValue);
}
}
}

LoginConflictListener:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* @author KJ
* @description 监听 session scope 的增删改:实现单点登录(SSO)冲突踢人
*/
// @WebListener
public class LoginConflictListener implements HttpSessionAttributeListener {
// 假设用一个全局 Map 存储 userId 和 Session 的对应关系
private static Map<String, HttpSession> userMap = new ConcurrentHashMap<>();

@Override
public void attributeAdded(HttpSessionBindingEvent event) {
if ("userId".equals(event.getName())) {
String userId = (String) event.getValue();

// 如果该用户已在其他地方登录
if (userMap.containsKey(userId)) {
HttpSession oldSession = userMap.get(userId);
oldSession.setAttribute("kickout", true); // 标记被踢
System.out.println("用户 " + userId + " 在新设备登录,旧设备将被强制下线。");
}
userMap.put(userId, event.getSession());
}
}
}

SensitiveDataListener:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* @author KJ
* @description 监听 request scope 的增删改:实现敏感数据脱敏
*/
// @WebListener
public class SensitiveDataListener implements ServletRequestAttributeListener {

@Override
public void attributeAdded(ServletRequestAttributeEvent event) {
String name = event.getName();
Object value = event.getValue();

// 典型业务:如果存入的是密码字段,强制脱敏处理或记录异常日志
if ("password".equalsIgnoreCase(name)) {
System.err.println("安全警告:检测到明文密码存入 Request 域!Key: " + name);
}
}

@Override
public void attributeReplaced(ServletRequestAttributeEvent event) {
// 当数据被覆盖时触发
}
}

CharacterEncodingFilter:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/**
* @author KJ
* @description 编码过滤器
*/
// @WebFilter(urlPatterns = {"/*"})
public class CharacterEncodingFilter implements Filter {
private String encoding;

@Override
public void init(FilterConfig filterConfig) throws ServletException {
// 从配置中读取编码格式,如果没有配置则默认 UTF-8
encoding = filterConfig.getInitParameter("encoding");
if (encoding == null) {
encoding = "UTF-8";
}
}

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// 防御逻辑
HttpServletRequest req = (HttpServletRequest) request;
String uri = req.getRequestURI();
if (uri.contains("/static/")) {
chain.doFilter(request, response); // 直接放行,不要设置编码
return;
}

// 1. 设置请求编码(解决 POST 请求乱码)
request.setCharacterEncoding(encoding);

// 2. 设置响应编码(解决浏览器显示乱码)
response.setCharacterEncoding(encoding);
response.setContentType("text/html;charset=" + encoding);

// 3. 核心:放行请求,让它流向下一个 Filter 或 Servlet
chain.doFilter(request, response);
}

@Override
public void destroy() {
// 销毁资源(通常留空)
}
}

AuthFilter:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// @WebFilter(urlPatterns = {"/admin/*", "*.do"})
// Servlet规范并没有提供直接在注解里设置顺序的参数,容器(如Tomcat)通常按照 类名的字母顺序来决定顺序。
// 这里我们将过滤器这类全局组件注册在 web.xml 中,谁写在前面就先走谁
public class AuthFilter implements Filter {

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {

HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse res = (HttpServletResponse) response;
String uri = req.getRequestURI();

// 放行静态资源目录或特定后缀
if (uri.endsWith(".css") || uri.endsWith(".js") || uri.contains("/static/")) {
chain.doFilter(request, response);
return;
}

// 放行登录页面和登录逻辑
if (uri.contains("login")) {
chain.doFilter(request, response);
return;
}

// 核心逻辑:从 Session 获取用户信息
Object user = req.getSession().getAttribute("user");
if (user != null) {
chain.doFilter(request, response); // 已登录,放行
} else {
// req.setAttribute("msg", "请先登录!");
// req.getRequestDispatcher("/login.jsp").forward(request, response);
res.sendRedirect("login.jsp?msg=Please LoginFirst");
}
}
}

DispatcherServlet:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
import jakarta.servlet.ServletException;
import jakarta.servlet.http.*;
import java.io.File;
import java.io.IOException;
import java.io.PrintWriter;
import java.lang.reflect.Method;
import java.util.UUID;

//@WebServlet(urlPatterns = "*.do", loadOnStartup = 1) // 从 Servlet 3.0 开始,你可以使用注解直接在 Java 类上定义路径
//@MultipartConfig(fileSizeThreshold = 1024 * 1024, maxFileSize = 1024 * 1024 * 5, maxRequestSize = 1024 * 1024 * 10)
public class DispatcherServlet extends HttpServlet { // 相当于 DispatcherServlet

/**
* 登录 (login.jsp) 验证身份,存 Session。
* 首页 (index.jsp) 看到欢迎词,点击 “个人中心” 卡片。
* 跳转 (DispatcherServlet) 反射处理逻辑。
* 个人中心 (profile.jsp) 异步上传头像,Session 更新。
* 返回首页头像也变了。
*/
@Override
protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
String uri = req.getRequestURI();
// 自动解析方法名:/login.do -> login
String methodName = uri.substring(uri.lastIndexOf("/") + 1, uri.lastIndexOf("."));

try {
Method method = this.getClass().getDeclaredMethod(methodName, HttpServletRequest.class, HttpServletResponse.class);
method.setAccessible(true); // 允许调用私有方法
method.invoke(this, req, resp);
} catch (NoSuchMethodException e) {
resp.sendError(404, "未找到该业务方法: " + methodName);
} catch (Exception e) {
e.printStackTrace();
resp.sendError(500, "服务器内部错误: " + e.getMessage());
}
}


// --- 1. 登录业务 ---
private void login(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
String username = req.getParameter("username");
String password = req.getParameter("password");
String rememberMe = req.getParameter("rememberMe");

if (username == null || username.trim().isEmpty() || password == null || password.trim().isEmpty()) {
req.setAttribute("msg", "账号或密码不能为空");
req.getRequestDispatcher("login.jsp").forward(req, resp);
return;
}

if ("admin".equals(username) && "123".equals(password)) {
// A. 会话管理:存入 Session 标识登录状态
HttpSession session = req.getSession();
session.setAttribute("user", username);

// 模拟从数据库读取头像
session.setAttribute("avatar", "static/images/default-avatar.png");

// B. 记住我逻辑:只有勾选了才存 Cookie
if ("on".equals(rememberMe)) {
// 存入用户名,有效期 7 天
Cookie userCookie = new Cookie("savedUsername", username);
userCookie.setMaxAge(60 * 60 * 24 * 7);
userCookie.setPath(req.getContextPath()); // 确保全站路径可用
resp.addCookie(userCookie);
} else {
// 如果没勾选,主动删除之前的 Cookie(安全性考虑)
Cookie killCookie = new Cookie("savedUsername", "");
killCookie.setMaxAge(0);
killCookie.setPath(req.getContextPath());
resp.addCookie(killCookie);
}

// C. 重定向到首页 (Redirect 防止表单重复提交)
resp.sendRedirect(req.getContextPath() + "/index.jsp");
} else {
req.setAttribute("msg", "用户名或密码错误");
req.getRequestDispatcher("login.jsp").forward(req, resp);
}
}


// --- 2. 注销业务 ---
private void logout(HttpServletRequest req, HttpServletResponse resp) throws IOException {
// 1. 获取当前 Session(如果不存在则不创建新的)
HttpSession session = req.getSession(false);
if (session != null) {
// 彻底销毁 Session 及其绑定的所有数据(如 user 对象)
session.invalidate();
}

// 2. 清理“记住我”的 Cookie
// 注意:Cookie 无法直接删除,必须通过创建一个同名、有效期为 0 的 Cookie 来覆盖
Cookie killCookie = new Cookie("savedUsername", "");
killCookie.setMaxAge(0); // 立即失效
killCookie.setPath(req.getContextPath()); // 路径必须与创建时完全一致
resp.addCookie(killCookie);

// 3. 记录日志(可选,结合你之前写的 LogListener)
System.out.println(">>> [用户退出] 已清理 Session 与 Cookie");

// 4. 重定向到登录页
// 提示:可以带一个参数让登录页显示“注销成功”
resp.sendRedirect(req.getContextPath() + "/login.jsp?msg=" + java.net.URLEncoder.encode("您已安全退出系统", "UTF-8"));
}


// --- 3. 页面跳转:进入个人中心 ---
private void profile(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
// 直接转发到刚才写的 profile.jsp
req.getRequestDispatcher("profile.jsp").forward(req, resp);
}


// --- 4. 异步业务:头像上传 (JSON 返回) ---
private void uploadAvatar(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
resp.setContentType("application/json;charset=UTF-8");
PrintWriter out = resp.getWriter();

try {
// 获取上传的文件部分
Part filePart = req.getPart("avatar");
if (filePart == null || filePart.getSize() == 0) {
out.print("{\"success\": false, \"message\": \"未检测到文件\"}");
return;
}

// 生成唯一文件名防止覆盖
String fileName = UUID.randomUUID().toString() + ".jpg";

// 确定物理存储路径:webapp/static/uploads
String uploadPath = getServletContext().getRealPath("/static/uploads");
System.out.println("文件实际保存位置: " + uploadPath); // xxx/target/spring-servlet-1.0-SNAPSHOT/static/uploads
File uploadDir = new File(uploadPath);
if (!uploadDir.exists()) uploadDir.mkdirs();

// 写入磁盘
filePart.write(uploadPath + File.separator + fileName);

// 更新 Session 状态
String avatarUrl = "static/uploads/" + fileName;
req.getSession().setAttribute("avatar", avatarUrl);

// 返回 JSON 给前端 fetch 调用
out.print("{\"success\": true, \"newAvatarUrl\": \"" + avatarUrl + "\"}");
} catch (Exception e) {
out.print("{\"success\": false, \"message\": \"服务器处理失败: " + e.getMessage() + "\"}");
}
}
}

login.jsp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%
// 1. 在页面渲染前,先处理逻辑数据
String savedUser = "";
boolean isRemembered = false;
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie c : cookies) {
if ("savedUsername".equals(c.getName())) {
savedUser = c.getValue();
isRemembered = true; // 发现 Cookie,默认勾选“记住我”
}
}
}

// 2. 获取 Servlet 传回的错误消息
String errorMsg = (String) request.getAttribute("msg");
%>

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${applicationScope.sysVersion != null ? applicationScope.sysVersion : "System"} - 登录</title>
<style>...</style>
</head>
<body>
<div class="login-card">
<h2>系统登录</h2>

<%-- 错误消息回显 --%>
<% if (errorMsg != null) { %>
<div class="error-msg">
<%= errorMsg %>
</div>
<% } %>

<form action="${pageContext.request.contextPath}/login.do" method="post">
<div class="form-group">
<label for="username">用户名</label>
<input type="text" id="username" name="username" value="<%= savedUser %>" placeholder="请输入账号" required autocomplete="off">
</div>

<div class="form-group">
<label for="password">密码</label>
<input type="password" id="password" name="password" placeholder="请输入密码" required>
</div>

<div class="checkbox-group">
<input type="checkbox" name="rememberMe" id="rememberMe" <%= isRemembered ? "checked" : "" %>>
<label for="rememberMe">记住我 (7天)</label>
</div>

<button type="submit" class="btn-submit">立即登录</button>
</form>

<div class="footer">
&copy; 2021 Powered by Owlias v1.3 Design
</div>
</div>
</body>
</html>

index.jsp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
<%-- 1. 指令 (Directives): 定义页面属性、导入包或包含其他文件 --%>
<%@ page contentType="text/html;charset=UTF-8" language="java" import="java.util.*, java.text.*" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>控制台 - Owlias v1.3</title>
</head>
<body>

<%-- 2. 脚本片段 (Scriptlets): 编写 Java 逻辑 --%>
<%
// 获取当前时间
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
String now = sdf.format(new Date());

// 获取 Session 中的用户信息 (由登录 Servlet 存入)
String user = (String) session.getAttribute("user");
if (user == null) {
user = "访客"; // 如果未登录,默认显示访客
}
%>

<div class="header">
<c:choose>
<c:when test="${not empty sessionScope.user}">
<span>欢迎,<c:out value="${sessionScope.user}" /></span>
<span class="tag">最后刷新: <%= now %></span>
</c:when>
<c:otherwise>
<a href="login.jsp" class="btn">请登录</a>
</c:otherwise>
</c:choose>
</div>

<div class="grid">
<div class="card">
<h3>1. 表达式 &lt;%= "... %&gt;" %></h3>
<p>用于直接向页面输出数据,末尾<strong>不加分号</strong>。</p>
<div class="code-block">
当前用户: <%= user %><br>
1+1 计算结果: <%= 1 + 1 %>
</div>
</div>

<div class="card">
<h3>2. 声明 &lt;%! "..." %&gt;</h3>
<p>用于定义成员变量或方法(属于 Servlet 类,非 service 方法内)。</p>
<%!
private int visitCount = 0;
public String getStatus(int count) {
return count > 10 ? "活跃" : "普通";
}
%>
<div class="code-block">
系统累计访问: <% visitCount++; %><%= visitCount %> 次<br>
当前状态: <%= getStatus(visitCount) %>
</div>
</div>

<div class="card">
<h3>3. EL 表达式 ${"... "}</h3>
<p>企业级首选,写法简洁,自动从各作用域查找对象。</p>
<div class="code-block">
项目路径: ${pageContext.request.contextPath}<br>
Session 用户: ${sessionScope.user}<br>
系统版本: ${applicationScope.sysVersion}
request 作用域参数:${requestScope}
</div>
</div>

<div class="card">
<h3>4. JSTL 标签 &lt;c:out&gt;</h3>
<p>逻辑控制(循环、判断)的最佳实践。需导入 taglib。</p>
<div class="code-block">
<c:if test="${not empty sessionScope.user}">
状态:已登录
</c:if>
<c:choose>
<c:when test="${user == 'admin'}">
权限:超级管理员
</c:when>
<c:otherwise>
权限:普通用户
</c:otherwise>
</c:choose>
</div>
</div>

<div class="card">
<h3>5. 注释</h3>
<p>JSP 注释在客户端不可见,HTML 注释在源码可见。</p>
<div class="code-block">
<%-- 这是 JSP 注释,不会发给浏览器 --%>
</div>
</div>

<div class="card">
<h3>6. 动作标签 &lt;jsp:...&gt;</h3>
<p>用于包含页面、转发或操作 Java Bean。</p>
<div class="code-block">
<%-- 动态包含页脚 --%>
<%-- <jsp:include page="footer.jsp" /> --%>
转发语法: &lt;jsp:forward page="url" /&gt;
</div>
</div>

<div class="card" onclick="location.href='${pageContext.request.contextPath}/profile.do'" style="cursor: pointer; border: 1px solid transparent; transition: all 0.3s ease;">
<div style="display: flex; align-items: center; gap: 15px;">
<img src="${not empty sessionScope.avatar ? sessionScope.avatar : 'static/images/default-avatar.png'}"
style="width: 50px; height: 50px; border-radius: 50%; object-fit: cover; border: 2px solid var(--primary);">
<div>
<h3 style="margin: 0; font-size: 18px;">个人中心</h3>
<p style="margin: 5px 0 0; font-size: 12px; color: #64748b;">查看资料、修改头像及账号设置</p>
</div>
</div>

<div style="margin-top: 15px; padding-top: 15px; border-top: 1px solid #f1f5f9; display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 12px; color: var(--primary);">立即进入 →</span>
<span class="tag">账号: ${sessionScope.user}</span>
</div>
</div>
</div>

<div style="margin-top: 30px; text-align: center;">
<a href="${pageContext.request.contextPath}/logout.do" style="color: #e74c3c; text-decoration: none;">安全退出系统</a>
</div>

</body>
</html>

profile.jsp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>个人中心 - Owlias v1.3</title>
<style>
:root {
--primary: #3498db;
--success: #2ecc71;
--bg: #f4f7f6;
--card-bg: #ffffff;
} ...
</style>
</head>
<body>

<div class="profile-card">
<div class="avatar-section">
<%-- 使用 EL 表达式读取 Session 中的头像,若为空则显示默认图 --%>
<img id="displayAvatar" class="avatar-img"
src="${not empty sessionScope.avatar ? sessionScope.avatar : 'static/images/default-avatar.png'}"
alt="User Avatar">
<label for="avatarFile" class="upload-badge" title="点击上传新头像">📸</label>
<input type="file" id="avatarFile" accept="image/*" onchange="handleFileUpload()">
</div>

<h2>${sessionScope.user}</h2>
<p style="color: #aaa; margin-top: -10px;">Owlias v1.3 认证用户</p>

<div class="user-info">
<div class="info-row">
<span class="label">账户类型</span>
<span class="value"><c:out value="${sessionScope.user == 'admin' ? '系统管理员' : '普通成员'}" /></span>
</div>
<div class="info-row">
<span class="label">所属部门</span>
<span class="value">技术开发部</span>
</div>
<div class="info-row">
<span class="label">在线状态</span>
<span class="value" style="color: var(--success);">● 运行中</span>
</div>
</div>
<div id="statusBanner"></div>
<a href="index.jsp" class="btn-back">← 返回控制台</a>
</div>

<script>
/**
* AJAX 异步上传逻辑
* 对应 DispatcherServlet 中的 uploadAvatar() 方法
*/
function handleFileUpload() {
const fileInput = document.getElementById('avatarFile');
const statusBanner = document.getElementById('statusBanner');
const displayAvatar = document.getElementById('displayAvatar');

if (!fileInput.files[0]) return;

// 构造上传数据
const formData = new FormData();
formData.append("avatar", fileInput.files[0]);

// 显示上传状态
statusBanner.style.display = 'block';
statusBanner.style.background = '#e3f2fd';
statusBanner.style.color = '#1976d2';
statusBanner.innerText = '正在上传头像...';

// 发起请求:路径对应 DispatcherServlet 的反射映射规则
fetch('${pageContext.request.contextPath}/uploadAvatar.do', {
method: 'POST',
body: formData
}).then(response => {
if (!response.ok) throw new Error('网络响应错误');
return response.json();
}).then(data => {
if (data.success) {
// 更新头像展示
displayAvatar.src = data.newAvatarUrl + '?t=' + new Date().getTime(); // 加时间戳防止缓存
statusBanner.style.background = '#e8f5e9';
statusBanner.style.color = '#2e7d32';
statusBanner.innerText = '头像更新成功!';
// 3秒后自动隐藏提示
setTimeout(() => { statusBanner.style.display = 'none'; }, 3000);
} else {
throw new Error(data.message);
}
}).catch(error => {
statusBanner.style.background = '#ffebee';
statusBanner.style.color = '#c62828';
statusBanner.innerText = '错误: ' + error.message;
});
}
</script>

</body>
</html>

404.jsp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<!DOCTYPE html>
<html>
<head>
<title>404 - 页面走丢了</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<div class="container">
<h1>404</h1>
<p>抱歉,您访问的资源不存在或已被移除。</p>
<a href="${pageContext.request.contextPath}/index.jsp" class="back-btn">返回首页</a>
</div>
</body>
</html>

500.jsp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<%@ page contentType="text/html;charset=UTF-8" language="java" isErrorPage="true" %>
<!DOCTYPE html>
<html>
<head>
<title>500 - 服务器开小差了</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<div class="error-box">
<h2>500 内部服务器错误</h2>
<p>工程师正在紧急修复中,请稍后再试。</p>
<button onclick="document.getElementById('debug').style.display='block'">查看技术细节</button>
<div id="debug" class="debug-info">
错误消息: <%= exception != null ? exception.getMessage() : "未知异常" %>
</div>
</div>
</body>
</html>

error.jsp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<%@ page contentType="text/html;charset=UTF-8" language="java" isErrorPage="true" %>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<h2>系统提示</h2>
<p>操作未能成功完成,原因如下:</p>
<div style="color: red;">
<%= exception %>
</div>
<hr>
<a href="javascript:history.back()">返回上一页</a>
</body>
</html>


注:JSP的九大内置对象

内置对象是指在 JSP 页面中不需要声明即可直接使用的对象。它们由 Servlet 容器(如 Tomcat)自动创建,简化了 Web 开发中处理请求、响应、会话等常见任务的复杂度。


解决中文乱码问题

GET 请求乱码:配置 Tomcat 容器

GET 请求的参数在 URL 中。在 Tomcat 8.0 及以上版本,默认编码已经是 UTF-8,通常不会乱码。如果你使用的是旧版本 Tomcat,或者需要显式指定,请修改 Tomcat 目录下的 conf/server.xml:

1
2
3
4
<Connector port="8080" protocol="HTTP/1.1"
connectionTimeout="20000"
redirectPort="8443"
URIEncoding="UTF-8" />


POST 请求乱码:使用过滤器(Filter)

POST 请求的参数在请求体中,最标准的方法是使用 CharacterEncodingFilter。

1
2
3
4
5
6
7
8
// 1. 设置请求编码(解决 POST 请求乱码)
request.setCharacterEncoding(encoding);
// 2. 设置响应编码(解决浏览器显示乱码)
response.setCharacterEncoding(encoding);
response.setContentType("text/html;charset=" + encoding);

// 在 jsp 中
<%@ page contentType="text/html;charset=UTF-8" language="java" %>

或者springmvc项目:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<filter>
<filter-name>CharacterEncodingFilter</filter-name>
<filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class>
<init-param>
<param-name>encoding</param-name>
<param-value>UTF-8</param-value>
</init-param>
<init-param>
<param-name>forceResponseEncoding</param-name>
<param-value>true</param-value>
</init-param>
</filter>
<filter-mapping>
<filter-name>CharacterEncodingFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>

或者 springboot项目:

1
2
3
server.servlet.encoding.charsets=UTF-8
server.servlet.encoding.enabled=true
server.servlet.encoding.force=true


Maven 编译编码

既然在打 WAR 包,也得确保 Maven 编译源码时也使用 UTF-8,否则代码里的中文常量在编译阶段就可能变质:

1
2
3
4
5
<properties>
<project.build.sourceEncoding>UTF-8</project-build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project-reporting.outputEncoding>
<maven.compiler.encoding>UTF-8</maven.compiler.encoding>
</properties>


配置IDEA的运行环境

IDEA 本身不带 Tomcat,你需要先去 Tomcat 官网 下载并解压一个版本(建议 Tomcat 10+ 对应 Jakarta EE)。

  1. 点击 IDEA 右上角的 Add Configuration…(或者 Edit Configurations…)。
  2. 点击左上角 + 号,选择 Tomcat Server -> Local。
  3. Application Server:点击 Configure…,选择你解压的 Tomcat 根目录。
  4. Deployment 选项卡(最关键):
    • 点击底部的 +
    • 选择 Artifact…
    • 选择 “项目名:war exploded”(这表示直接运行编译后的文件夹,改代码后生效快)
    • Application context:建议设为 “/“(这样访问地址就是 localhost:8080/hello,不需要带长长的项目名)


运行验证效果


SpringMVC 是怎么接入 Servlet 的

Spring MVC 并不是凭空产生的,它是建立在 Servlet 规范之上的。在它的体系架构中,最核心的 Servlet 只有一个,但它继承了一套严密的家族体系。以下是 Spring MVC 集成 Servlet API 时最核心的类及其职责:

1. 核心 Servlet 类继承体系

在 Spring MVC 中,所有的请求处理最终都汇聚在一个类上。我们来看它的“族谱”:


2. Spring MVC 核心三剑客

虽然物理上的 Servlet 只有一个,但 Spring MVC 内部有三个关键组件(它们紧密依赖 Servlet API)来完成我们刚才使用 servlet 实现的功能:

HandlerMapping (映射器)

  • 对应代码:对应刚才我们通过 uri.substring 截取方法名的逻辑。
  • 职责:根据请求的 URL 找到对应的 Controller 和 Method。

HandlerAdapter (适配器)

  • 对应代码:对应刚才的 method.invoke(this, req, resp) 的反射调用逻辑。
  • 职责:负责具体的调用。因为 Spring 的 Controller 方法参数千奇百怪(有的要 Session,有的要 POJO),适配器负责把 Servlet 的 req/resp 转换成方法需要的参数。

ViewResolver (视图解析器)

  • 对应代码:对应刚才的 req.getRequestDispatcher(“profile.jsp”).forward(…)。
  • 职责:将 Controller 返回的逻辑视图名(如 “profile”)加工成真实的物理路径(如 “/WEB-INF/jsp/profile.jsp”)。


3. Spring MVC 还会用到哪些原生 Servlet 组件?

除了 DispatcherServlet,Spring MVC 在集成时还会高频使用以下 Servlet API 原生类:

  • ContextLoaderListener

    • 本质:一个标准的 ServletContextListener。
    • 作用:在 Tomcat 启动时,加载 applicationContext.xml,初始化 Service 层和 DAO 层的 Bean。
  • CharacterEncodingFilter

    • 本质:一个标准的 Filter。
    • 作用:统一解决乱码问题(类似于我们刚刚写的编码设置)。
  • DelegatingFilterProxy

    • 本质:一个代理 Filter。
    • 作用:让 Servlet 容器中的 Filter 能够使用 Spring 容器里的 Bean(打破了 Servlet 容器与 Spring 容器的壁垒)。

所以总结起来:Spring MVC 的核心代码其实就是刚才我们写的那个 DispatcherServlet 的极端增强版。DispatcherServlet 的 service 方法大致执行流程是:

  1. 通过 HandlerMapping 找是谁处理(Controller)。
  2. 通过 HandlerAdapter 去执行(Invoke)。
  3. 如果执行过程中报错,交给 HandlerExceptionResolver(异常处理)。
  4. 执行正常得到 ModelAndView。
  5. 通过 ViewResolver 渲染成 HTML 发给浏览器。

除此之外,SpringMVC 做的更多是兼容性、解耦和功能扩展的功能 ,比如数据绑定、数据校验、拦截器等等。关于 SpringMVC 的更具体的说明,我们以后再做探讨,今天先写到这儿吧,睡觉~ 😴。